Skip to content

Handle already-running containers and port conflicts#40

Open
carole-lavillonniere wants to merge 4 commits intomainfrom
carole/drg-357
Open

Handle already-running containers and port conflicts#40
carole-lavillonniere wants to merge 4 commits intomainfrom
carole/drg-357

Conversation

@carole-lavillonniere
Copy link
Collaborator

Before pulling images or validating licenses, lstk start now checks the state of each configured container:

  • If a container is already running, it logs "<name> is already running" and skips it. If all containers are already running, the command exits successfully without doing any work.
  • If the configured port is already bound by another process, it returns an error "port <N> already in use" with a hint pointing to the config file.

@carole-lavillonniere
Copy link
Collaborator Author

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Feb 17, 2026

📝 Walkthrough

Walkthrough

Implements a staged container startup pipeline: select containers to start (skip running / check ports), pre-pull images, validate licenses per container, then start containers with readiness checks; also extends container config with Tag and ProductName and updates integration tests to use shared test scaffolding.

Changes

Cohort / File(s) Summary
Container Startup Pipeline
internal/container/start.go
Introduces staged startup flow: selectContainersToStart, checkPortAvailable, pullImages, validateLicenses, startContainers. Replaces inline single-pass start logic, adds port checks and readiness/health polling, and switches references to container-scoped fields (Tag, ProductName, Name, Image, Port, HealthPath).
Runtime API
internal/runtime/runtime.go
Adds Tag string and ProductName string to runtime.ContainerConfig struct.
Integration Test scaffolding
test/integration/main_test.go
Adds shared Docker test helpers and constants (containerName, testImage) and startTestContainer helper to centralize test container lifecycle code.
Integration Tests: start & license
test/integration/start_test.go, test/integration/license_test.go
Adds tests for "already running" and "port in use" scenarios; updates license_test.go to use consolidated containerName and adjusts inspection/cleanup accordingly.
Integration Tests: stop
test/integration/stop_test.go
Removes standalone startTestContainer and testImage from this file to rely on shared helpers in main_test.go.

Sequence Diagram(s)

mermaid
sequenceDiagram
participant CLI as Start Command
participant RT as Runtime
participant Sink as Output Sink
participant API as PlatformAPI
participant Docker as Docker Engine

CLI->>RT: call selectContainersToStart(containers)
RT->>RT: filter running containers; call checkPortAvailable
RT->>Sink: emit skipped/selected messages
CLI->>RT: call pullImages(selected)
RT->>Docker: pull image(s) with progress
Docker-->>RT: pull progress / result
RT->>Sink: emit pull progress/errors
CLI->>RT: call validateLicenses(selected)
RT->>API: query license info per container (using ProductName/Tag)
API-->>RT: license responses
RT->>Sink: emit license validation results
CLI->>RT: call startContainers(selected)
RT->>Docker: create/start container
Docker-->>RT: start result
RT->>RT: wait for readiness / poll HealthPath
RT->>Sink: emit start/ready/health status

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main changes: handling already-running containers and port conflicts, which aligns with the primary objectives of the PR.
Description check ✅ Passed The description directly relates to the changeset, explaining the new behavior for checking container state and port availability before pulling images or validating licenses.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch carole/drg-357

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/container/start.go`:
- Around line 57-75: The token is currently retrieved before
selectContainersToStart which prevents early-return when no containers need
starting; move the token retrieval to after selectContainersToStart and the
len(containers)==0 check so lstk can fast-exit without failing on missing token.
Update the flow in start.go: call selectContainersToStart(...) first, if
len(containers)==0 return nil, then fetch the token (the existing token
retrieval logic), then ensure the filtered containers (the returned containers
value) are injected/associated with the token before calling pullImages(...),
validateLicenses(..., token) and startContainers(...). Adjust any code that
passes cfg.Containers or containers to validateLicenses to use the token-aware
container set.

Copy link
Member

@silv-io silv-io left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Just some thoughts that popped up

return nil
}

func selectContainersToStart(ctx context.Context, rt runtime.Runtime, sink output.Sink, containers []runtime.ContainerConfig, cfgContainers []config.ContainerConfig) ([]runtime.ContainerConfig, []config.ContainerConfig, error) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thought: that's a long line. Wild that gofmt doesn't split it up :) Maybe gofumpt would?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah gofmt does not care about line length and leave it up to the coder to wrap.
So if we want it we'd need to introduce another tool.

Comment on lines 129 to 130
var filtered []runtime.ContainerConfig
var filteredCfg []config.ContainerConfig
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thought: I wonder if we could somehow compose those together so that we don't have to handle them separately, like having runtime.ContainerConfig contain config.ContainerConfig, somewhat like this:

type ContainerConfig struct {
	Image      string
	Name       string
	Port       string
	HealthPath string
	Env        []string // e.g., ["KEY=value", "FOO=bar"]
	*config.ContainerConfig
}

Not sure if it wouldn't make things annoying somewhere else though :)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a fair point, it looks quite complex at the moment.
That suggestion would require internal/runtime to import internal/config though. It could cause import cycle.
What about this instead? 0227242
I added Tag and ProductName to runtime.ContainerConfig

Comment on lines 153 to 163
func checkPortAvailable(port string) error {
conn, err := net.DialTimeout("tcp", "localhost:"+port, time.Second)
if err != nil {
return nil
}
err = conn.Close()
if err != nil {
return nil
}
return fmt.Errorf("port %s already in use", port)
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thought: I wonder if it makes sense to have that somewhere in a util/x, but we might only need it here.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense, moved it to internal/ports f4f420e

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
test/integration/start_test.go (1)

106-124: Same environment variable consistency issue; consider binding to localhost explicitly.

Same suggestion as above regarding env.With() usage.

The static analysis warning about binding to all interfaces is a false positive in this test context—you intentionally need to occupy the port. However, binding to 127.0.0.1:4566 instead of :4566 would be slightly more precise since checkPortAvailable in start.go dials localhost.

♻️ Optional: more precise binding
-	ln, err := net.Listen("tcp", ":4566")
+	ln, err := net.Listen("tcp", "127.0.0.1:4566")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/integration/start_test.go` around lines 106 - 124, In
TestStartCommandFailsWhenPortInUse update the port binding to be explicit to
localhost by changing the net.Listen call in TestStartCommandFailsWhenPortInUse
from ":4566" to "127.0.0.1:4566" so it matches how checkPortAvailable dials
localhost; also make the environment-setting consistent by replacing the manual
append to cmd.Env (cmd.Env = append(os.Environ(),
"LOCALSTACK_AUTH_TOKEN=fake-token")) with the project’s env helper (e.g.,
env.With or equivalent used elsewhere) to keep env construction consistent
across tests.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/container/start.go`:
- Around line 157-167: In checkPortAvailable, when net.DialTimeout succeeds it
indicates the port is in use, so always return the "port %s already in use"
error regardless of conn.Close() result; do not return nil on a Close error.
Update the function (checkPortAvailable) to defer or call conn.Close() but
ignore or log any Close error and then return fmt.Errorf("port %s already in
use", port) after a successful DialTimeout, ensuring Dial success drives the
error path.

In `@test/integration/start_test.go`:
- Around line 88-104: The test TestStartCommandDoesNothingWhenAlreadyRunning
uses append(os.Environ(), ...) and the wrong env var name; replace that with the
project's env helper so the CLI reads the correct token: use
env.With(env.AuthToken, "fake-token") for the cmd.Env setup (the same approach
used in other tests), i.e. update the exec.CommandContext invocation in
TestStartCommandDoesNothingWhenAlreadyRunning to set cmd.Env via env.With and
reference env.AuthToken instead of "LOCALSTACK_AUTH_TOKEN" so startTestContainer
and the CLI see the correct LSTK_AUTH_TOKEN value.

---

Duplicate comments:
In `@internal/container/start.go`:
- Around line 27-30: Token is still fetched before selecting containers; change
the flow in start.go so selectContainersToStart(...) is called first and its
result examined, and only if there are containers to start call a.GetToken(ctx)
and proceed; specifically, move the GetToken call to after
selectContainersToStart and guard token retrieval and any subsequent operations
on token by checking that the returned list from selectContainersToStart is
non-empty (use the selectContainersToStart return value and the GetToken method
names to locate where to reorder).

---

Nitpick comments:
In `@test/integration/start_test.go`:
- Around line 106-124: In TestStartCommandFailsWhenPortInUse update the port
binding to be explicit to localhost by changing the net.Listen call in
TestStartCommandFailsWhenPortInUse from ":4566" to "127.0.0.1:4566" so it
matches how checkPortAvailable dials localhost; also make the
environment-setting consistent by replacing the manual append to cmd.Env
(cmd.Env = append(os.Environ(), "LOCALSTACK_AUTH_TOKEN=fake-token")) with the
project’s env helper (e.g., env.With or equivalent used elsewhere) to keep env
construction consistent across tests.

Comment on lines 157 to 167
func checkPortAvailable(port string) error {
conn, err := net.DialTimeout("tcp", "localhost:"+port, time.Second)
if err != nil {
return nil
}
err = conn.Close()
if err != nil {
return nil
}
return fmt.Errorf("port %s already in use", port)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Bug: returns nil when conn.Close() fails, masking the port-in-use condition.

If DialTimeout succeeds (meaning the port is in use) but conn.Close() subsequently fails, the function incorrectly returns nil instead of the "port already in use" error. The close error is irrelevant to the port availability check.

🐛 Proposed fix
 func checkPortAvailable(port string) error {
 	conn, err := net.DialTimeout("tcp", "localhost:"+port, time.Second)
 	if err != nil {
 		return nil
 	}
-	err = conn.Close()
-	if err != nil {
-		return nil
-	}
+	_ = conn.Close()
 	return fmt.Errorf("port %s already in use", port)
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func checkPortAvailable(port string) error {
conn, err := net.DialTimeout("tcp", "localhost:"+port, time.Second)
if err != nil {
return nil
}
err = conn.Close()
if err != nil {
return nil
}
return fmt.Errorf("port %s already in use", port)
}
func checkPortAvailable(port string) error {
conn, err := net.DialTimeout("tcp", "localhost:"+port, time.Second)
if err != nil {
return nil
}
_ = conn.Close()
return fmt.Errorf("port %s already in use", port)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/container/start.go` around lines 157 - 167, In checkPortAvailable,
when net.DialTimeout succeeds it indicates the port is in use, so always return
the "port %s already in use" error regardless of conn.Close() result; do not
return nil on a Close error. Update the function (checkPortAvailable) to defer
or call conn.Close() but ignore or log any Close error and then return
fmt.Errorf("port %s already in use", port) after a successful DialTimeout,
ensuring Dial success drives the error path.

Comment on lines +88 to +104
func TestStartCommandDoesNothingWhenAlreadyRunning(t *testing.T) {
requireDocker(t)
cleanup()
t.Cleanup(cleanup)

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

startTestContainer(t, ctx)

cmd := exec.CommandContext(ctx, binaryPath(), "start")
cmd.Env = append(os.Environ(), "LOCALSTACK_AUTH_TOKEN=fake-token")
output, err := cmd.CombinedOutput()

require.NoError(t, err, "lstk start should succeed when container is already running: %s", output)
assert.Contains(t, string(output), "already running")
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for env helper and AuthToken definition
fd -t f "env" test/integration/ | head -20

Repository: localstack/lstk

Length of output: 86


🏁 Script executed:

#!/bin/bash
# Look for the env package structure
find test/integration -type f -name "*.go" | xargs grep -l "env\\.With\\|env\\.AuthToken" | head -10

Repository: localstack/lstk

Length of output: 153


🏁 Script executed:

#!/bin/bash
# Search for AuthToken definition in the env helper
rg -n "AuthToken\s*=" test/integration/ --type go

Repository: localstack/lstk

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Check if the start command uses Bubble Tea
rg -n "Bubble Tea|bubbletea" --type go | head -20

Repository: localstack/lstk

Length of output: 512


🏁 Script executed:

#!/bin/bash
# Check other tests in start_test.go to see env.With usage
head -100 test/integration/start_test.go | rg -n "env\\.With\\|os\\.Environ"

Repository: localstack/lstk

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Read the env helper file to see what AuthToken maps to
cat test/integration/env/env.go

Repository: localstack/lstk

Length of output: 1110


🏁 Script executed:

#!/bin/bash
# Check the start_test.go file to see env.With and os.Environ patterns
cat test/integration/start_test.go

Repository: localstack/lstk

Length of output: 4026


🏁 Script executed:

#!/bin/bash
# Search for where LOCALSTACK_AUTH_TOKEN or similar env vars are used
rg -n "LOCALSTACK_AUTH_TOKEN|LSTK_AUTH_TOKEN" --type go

Repository: localstack/lstk

Length of output: 937


🏁 Script executed:

#!/bin/bash
# Check if start command has Bubble Tea UI
rg -n "tea\\.|bubbletea" cmd/ internal/ --type go | grep -i start

Repository: localstack/lstk

Length of output: 41


Use env.With() for environment variable consistency and correct env var name.

This test (and the one at line 106) use append(os.Environ(), "LOCALSTACK_AUTH_TOKEN=fake-token"), inconsistent with earlier tests in the file that use env.With(). More importantly, LOCALSTACK_AUTH_TOKEN is incorrect—that's the variable the CLI passes to the container. The CLI reads from LSTK_AUTH_TOKEN (via env.AuthToken). Use env.With(env.AuthToken, "fake-token") instead.

♻️ Suggested fix
-	cmd.Env = append(os.Environ(), "LOCALSTACK_AUTH_TOKEN=fake-token")
+	cmd.Env = env.With(env.AuthToken, "fake-token")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func TestStartCommandDoesNothingWhenAlreadyRunning(t *testing.T) {
requireDocker(t)
cleanup()
t.Cleanup(cleanup)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
startTestContainer(t, ctx)
cmd := exec.CommandContext(ctx, binaryPath(), "start")
cmd.Env = append(os.Environ(), "LOCALSTACK_AUTH_TOKEN=fake-token")
output, err := cmd.CombinedOutput()
require.NoError(t, err, "lstk start should succeed when container is already running: %s", output)
assert.Contains(t, string(output), "already running")
}
func TestStartCommandDoesNothingWhenAlreadyRunning(t *testing.T) {
requireDocker(t)
cleanup()
t.Cleanup(cleanup)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
startTestContainer(t, ctx)
cmd := exec.CommandContext(ctx, binaryPath(), "start")
cmd.Env = env.With(env.AuthToken, "fake-token")
output, err := cmd.CombinedOutput()
require.NoError(t, err, "lstk start should succeed when container is already running: %s", output)
assert.Contains(t, string(output), "already running")
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/integration/start_test.go` around lines 88 - 104, The test
TestStartCommandDoesNothingWhenAlreadyRunning uses append(os.Environ(), ...) and
the wrong env var name; replace that with the project's env helper so the CLI
reads the correct token: use env.With(env.AuthToken, "fake-token") for the
cmd.Env setup (the same approach used in other tests), i.e. update the
exec.CommandContext invocation in TestStartCommandDoesNothingWhenAlreadyRunning
to set cmd.Env via env.With and reference env.AuthToken instead of
"LOCALSTACK_AUTH_TOKEN" so startTestContainer and the CLI see the correct
LSTK_AUTH_TOKEN value.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

Comments